14장 여러 자원 엮어 쓰기 — 입문판
원서: API Design Patterns (Manning, JJ Geewax) 14장을 입문자용으로 다시 풀어 썼습니다. 1차 표준 출처: 이 장의 연관 리소스 패턴은 Google의 API 설계 가이드라인인 AIP-124(Resource association)에 기반합니다. 원전은 https://google.aip.dev/124 에서 볼 수 있어요.
AIP가 뭐예요? AIP는 API Improvement Proposals(API 개선 제안)의 줄임말이에요. Google이 자사 API를 설계할 때 쓰는 공개 규칙 모음이고, google.aip.dev 에 전부 공개돼 있어요. 일상 비유로, 회사마다 있는 "문서 작성 사규집" 같은 거예요. (모두가 같은 양식으로 쓰도록 정해둔 규칙집) 이 입문판이 풀어 쓴 내용이 어디서 왔는지 궁금하면, 원서와 함께 이 AIP 문서를 1차 출처로 보면 됩니다. 그중 연관 리소스를 다루는 항목이 AIP-124예요. 이 장을 다 읽은 뒤 원문과 대조하면 같은 이야기를 더 압축된 영어 규칙으로 만날 수 있어요.
이 장에서 딱 6가지만: 1. 다대다 관계가 뭐고, 왜 단순 참조로는 안 터지는지 2. 연관 리소스(관계를 따로 만든 자원)가 뭔지 3. 연관 리소스에 어떤 메서드를 붙이는지 (만들기·지우기·나열하기) 4. 같은 짝은 하나만, 양쪽 이름표는 못 바꾼다 (고유성·읽기 전용) 5. 자주 쓰는 조회를 줄여 쓰는 별칭 메서드 6. 가리키던 대상이 사라질 때 어떻게 처리하나 (참조 무결성)
이 장을 다 읽으면 이런 걸 할 수 있어요.
- 일대다 관계와 다대다 관계를 구분한다.
- 연관 리소스가 필요한 상황을 설명한다.
- 연관 리소스의 표준 메서드와 별칭 메서드를 구분한다.
- 고유성 제약과 읽기 전용 필드가 왜 있는지 설명한다.
- 참조 무결성의 4가지 처리 방식을 구분한다.
1. 왜 이런 게 필요할까 — 단순 참조로 짜면 터진 적 있죠?
학생과 동아리를 연결하는 API를 만든다고 해봅시다.
처음엔 이렇게 짜요. 학생(User) 안에 "내가 속한 동아리 목록"을 적어두는 거죠.
User {
id: "users/jimmy",
name: "지미",
groupIds: ["groups/축구부", "groups/밴드부"] // 속한 동아리 목록
}
깔끔해 보이는데, 곧 이런 질문이 들어옵니다. "지미가 축구부에서 회장이야 부원이야?" "지미는 언제 가입했어?"
이 정보를 어디 적죠? User에 적자니 동아리마다 역할이 다르고, Group에 적자니 사람마다 역할이 다릅니다. 양쪽 다 어색해요.
또 다른 질문. "축구부에 속한 학생 전부 보여줘." 그러려면 모든 학생을 한 명씩 다 뒤져서 groupIds에 축구부가 있나 확인해야 해요. 학생이 100만 명이면? 전부 스캔. 느립니다.
이렇게 한 명이 여러 곳에, 그 여러 곳도 다시 여러 명을 갖는 관계, 그리고 그 관계 자체에 적어둘 정보가 있을 때 단순 참조는 터집니다.
그게 바로 다대다 관계예요.
다대다 관계: 한쪽이 여러 상대와 연결되고, 그 상대도 다시 여러 쪽과 연결되는 관계. 일상 비유로, 학생-동아리 같은 거예요. (학생 한 명이 동아리 여럿, 동아리 하나에 학생 여럿)
관계 메타데이터: 관계 그 자체에 붙는 추가 정보. 일상 비유로, "이 사람이 이 동아리에서 회장이고, 작년 3월에 들어왔다" 같은 꼬리표예요.
비유부터 표로 연결
| 비유 | API/코드 | 위험·주의 |
|---|---|---|
| 학생 한 명이 동아리 여럿에, 동아리 하나에 학생 여럿 | 다대다 관계 | 단순 배열 참조로는 관계 정보를 못 적음 |
| "회장이냐 부원이냐", "언제 가입했냐" 꼬리표 | 관계 메타데이터 | User에도 Group에도 두기 어색함 |
| "축구부 학생 전부 보여줘" | 양방향 조회 | 배열 방식은 전체 스캔이라 느림 |
잘못된 예 vs 올바른 예
잘못된 예 — 관계 정보를 User 안에 욱여넣기
User {
groupIds: ["groups/축구부"],
roleInSoccer: "회장", // 동아리 늘면 필드도 계속 늘어남
roleInBand: "부원"
}
올바른 예 — 관계를 따로 빼서 자원으로 만들기 (다음 절에서 설명)
Membership {
userId: "users/jimmy",
groupId: "groups/축구부",
role: "회장",
joinedAt: "2024-03"
}
한 걸음 더 ▸ 데이터베이스를 아는 분께: 위 Membership은 DB의 join table(다대다를 저장하는 중간 표)과 똑같은 발상이에요. DB에서 하던 걸 API 자원으로 노출한 것뿐입니다. 몰라도 다음 내용 이해엔 지장 없어요.
2. 연관 리소스 — 관계를 아예 자원으로 만든다
앞에서 본 문제의 답은 간단해요. 관계 자체를 하나의 자원으로 만드는 것입니다.
이걸 연관 리소스라고 불러요.
연관 리소스: 두 자원 사이의 다대다 관계를 표현하려고 따로 만든 독립 자원. 일상 비유로, 결혼 증명서 같은 거예요. (신랑·신부 두 사람을 엮은 종이 한 장이 따로 존재)
학생-동아리 예에서는 이 종이의 이름이 Membership(멤버십)입니다.
Membership {
id: "memberships/cm-1",
userId: "users/jimmy", // 누구와
groupId: "groups/축구부", // 어디를 엮었나
role: "회장", // 관계에 붙는 정보
joinedAt: "2024-03-15"
}
이제 "만들기 = 가입", "지우기 = 탈퇴"가 됩니다. 가입하면 종이 한 장 발급, 탈퇴하면 종이 파기.
연관 리소스가 주는 3가지
- 관계를 만들고·확인하고·지울 수 있다 (가입/조회/탈퇴)
- 관계에 정보를 붙일 수 있다 (역할, 가입일, 만료일)
- 양쪽 모두에서 조회된다 ("이 학생의 동아리는?" + "이 동아리의 학생은?")
비유부터 표로 연결
| 비유 | API/코드 | 위험·주의 |
|---|---|---|
| 결혼 증명서 한 장 | Membership 자원 1개 | 어느 한쪽의 소유물이 아니라 독립 자원 |
| 종이 발급 = 가입 | Create(POST) | 같은 짝 종이는 하나만 (3절) |
| 종이 파기 = 탈퇴 | Delete(DELETE) | 종이만 지움, 사람·동아리는 그대로 |
예시 2개
예시 1 — Slack 채널 멤버십
Alice가 #general에서는 admin,
Alice가 #engineering에서는 member.
같은 사람인데 채널마다 역할이 달라요.
그래서 역할은 "사람"이 아니라 "관계"에 붙어야 합니다.
ChannelMembership { userId: "Alice", channelId: "#general", role: "admin" }
ChannelMembership { userId: "Alice", channelId: "#engineering", role: "member" }
예시 2 — GitHub 저장소 협업자
RepositoryCollaboration {
userId: "users/alice",
repositoryId: "repos/my-repo",
permission: "write", // 권한 수준
invitedAt: "2024-03-15" // 초대 시점
}
한 걸음 더 ▸ 일대다라면 13장(교차 참조)이 더 단순해요. "책 한 권에 저자 한 명"처럼 한쪽만 여럿이면 그냥 authorId 하나 두면 끝. 연관 리소스는 양쪽 다 여럿일 때 꺼내는 카드예요.
3. 처음부터 끝까지 따라가기 — 온라인 강의 플랫폼
머리로만 보면 안 와닿으니, 강의 플랫폼(인프런 같은)을 한 번 만들어 봅시다.
요구사항부터.
- 학생은 여러 강의를 듣는다
- 강의에는 여러 학생이 있다
- 관계에 정보가 필요하다:
· 수강 시작일 (enrolledAt)
· 진도율 (progress, 0~100)
· 수료 여부 (completed)
· 수강권 만료일 (expireTime)
다대다 + 관계 정보. 연관 리소스 자리예요. 이름은 도메인 용어를 살려 CourseEnrollment(수강 등록)로 합니다.
먼저 안 되는 길을 밟아보기
길 1 — 학생 안에 courseIds 배열 (1절에서 본 그 방식)
Student { courseIds: ["courses/python-101"] }
문제: 진도율을 어디 적지?
강의마다 진도가 다른데 Student.progress 하나로는 표현 불가.
필드를 맵으로 늘리면 만료일·수료여부까지 맵 폭발.
이건 일대다 + 정보 없을 때만 맞는 길이에요. 여기선 부적합.
길 2 — 그냥 add/remove (학생을 강의에 "넣기/빼기")
POST /courses/python-101/students:add { studentId: "alice" }
→ 넣는 건 되는데, 진도율 75%를 어디 적지? 못 적음.
이건 다대다지만 정보 없을 때만 맞는 길. 여기서도 부적합.
add/remove(넣기/빼기)는 관계의 "있다/없다"만 다뤄요. 정보를 못 붙입니다. 자세한 건 다음 장 예고에서 한 줄 더.
되는 길 — 연관 리소스 설계
CourseEnrollment {
id: "enrollments/enr-001",
studentId: "students/alice", // 읽기 전용 (어떤 학생)
courseId: "courses/python-101", // 읽기 전용 (어떤 강의)
enrolledAt: "...", // 서버가 자동 기록
progress: 0, // 진도율
completed: false, // 수료 여부
expireTime: "..." // 만료일
}
DB로 치면 이렇게 생긴 표예요.
CREATE TABLE enrollments (
id TEXT PRIMARY KEY,
student_id TEXT NOT NULL REFERENCES students(id),
course_id TEXT NOT NULL REFERENCES courses(id),
progress INTEGER DEFAULT 0,
completed BOOLEAN DEFAULT FALSE,
expire_time DATETIME,
UNIQUE(student_id, course_id) -- 같은 학생+강의 짝은 1개만!
);
실제 호출 흐름 (짧게)
1) 수강 등록 = 연관 리소스 생성
POST /enrollments { studentId: "alice", courseId: "python-101" }
→ 201 Created (progress: 0, completed: false 로 시작)
2) 중복 등록 시도 → 막힘
POST /enrollments { studentId: "alice", courseId: "python-101" }
→ 409 Conflict "이미 수강 중입니다"
3) 진도 업데이트 (정보만 수정)
PATCH /enrollments/enr-001 { progress: 50 }
→ progress만 50으로, studentId·courseId는 그대로
4) 수료 처리
PATCH /enrollments/enr-001 { progress: 100, completed: true }
5) 수강 취소 = 연관 리소스 삭제
DELETE /enrollments/enr-002
→ 204 No Content
이 시나리오의 한 문장
관계 자체에 대한 질문("Alice의 진도율은?", "수료했나?", "언제 만료?")이 생기면, 그 관계는 자원이 되어야 합니다.
비유부터 표로 연결
| 비유 | API/코드 | 위험·주의 |
|---|---|---|
| 수강증 발급 | POST /enrollments | 같은 강의 수강증은 1장만 |
| 수강증에 진도 도장 찍기 | PATCH (progress 수정) | 누구·어떤 강의 칸은 못 고침 |
| 수강증 반납 | DELETE /enrollments/{id} | 학생·강의는 안 지워짐 |
4. 연관 리소스에 이름 붙이기
연관 리소스도 이름이 필요해요. 규칙은 단순합니다. 도메인에 자연스러운 말이 있으면 그걸 먼저 쓰고, 없으면 정해진 접미사를 붙입니다.
| 방식 | 예시 | 언제 |
|---|---|---|
| 도메인 용어 (가장 좋음) | CourseEnrollment, Subscription | 이미 적절한 말이 있을 때 |
| 이름 + Membership | ChannelMembership, TeamMembership | "멤버"가 자연스러울 때 |
| 이름 + Association | UserProjectAssociation | 중립적 관계일 때 |
| 그냥 Membership | Membership | 관계 종류가 하나뿐이고 맥락이 분명할 때 |
Membership(멤버십): "~의 일원임"이라는 뜻. 일상 비유로, 헬스장 회원증 같은 거예요. Association(어소시에이션): "엮임·연결"이라는 중립적인 말이에요.
잘못된 예 vs 올바른 예 (이름 짓기)
강사 ↔ 강의
잘못: CourseInstructorship (어색하고 길다)
올바름: CourseTeaching 또는 InstructorAssignment
상품 ↔ 카테고리
올바름: ProductCategorization 또는 ProductCategoryMapping
사용자 ↔ 쿠폰
올바름: CouponAssignment
5. 어떤 메서드를 붙이나 — 표준 메서드
연관 리소스도 보통 자원처럼 다룹니다. 다만 꼭 필요한 것과 있으면 좋은 것이 나뉘어요.
| 메서드 | 하는 일 | 필요성 |
|---|---|---|
| Create | 두 자원 연결 (가입) | 필수 |
| Delete | 연결 제거 (탈퇴) | 필수 |
| List | 연결들 나열 | 필수 |
| Get | 관계 정보 하나 확인 | 정보 있을 때만 |
| Update | 관계 정보 수정 | 정보 있을 때만 |
외우기 쉽게: 만들기·지우기·나열하기는 항상, 조회·수정은 붙일 정보가 있을 때만.
채널 멤버십 CRUD 한 바퀴
1. CREATE
POST /memberships { channelId: "engineering", userId: "alice", role: "member" }
→ 201 (joinedAt은 서버가 자동 기록)
2. GET
GET /memberships/cm-101
→ 그 멤버십 하나의 정보
3. LIST (필터 가능)
GET /memberships?filter=channelId='engineering'
→ 그 채널의 멤버십 목록 (+ nextPageToken 페이징)
4. UPDATE (정보만)
PATCH /memberships/cm-101 { role: "admin" }
→ role만 바뀜
5. DELETE
DELETE /memberships/cm-101
→ 204
정보가 없으면 더 단순하게
게시글-태그처럼 붙일 정보가 없으면 Get·Update가 필요 없어요.
PostTagging { postId, tagId } // 정보 없음
→ Create, List, Delete만 있으면 충분
POST /taggings, GET /taggings, DELETE /taggings/{id}
비유부터 표로 연결
| 비유 | API/코드 | 위험·주의 |
|---|---|---|
| 회원 등록 | Create (필수) | 빠지면 가입 자체가 불가 |
| 회원 탈퇴 | Delete (필수) | 빠지면 탈퇴 불가 |
| 회원 명부 보기 | List (필수) | 빠지면 누가 있는지 모름 |
| 회원증 하나 들춰보기 | Get (정보 있을 때) | 정보 없으면 생략 가능 |
6. 같은 짝은 하나만 — 고유성
Alice를 #engineering에 두 번 넣으면 어떻게 될까요?
POST /memberships { userId: "alice", channelId: "engineering" }
→ 201 (처음이니 성공)
POST /memberships { userId: "alice", channelId: "engineering" }
→ 409 Conflict (이미 있음!)
두 번째는 막아야 해요. 안 막으면 이런 일이 터집니다.
- 멤버 목록에 Alice가 두 번 뜸 (혼란)
- 역할을 바꿀 때 둘 중 뭘 바꿔야 할지 모호
- 탈퇴할 때 하나만 지우면 관계가 남아있음
이게 고유성 제약이에요.
고유성 제약: 같은 짝을 잇는 연관 리소스는 딱 하나만 있어야 한다는 규칙. 일상 비유로, 같은 두 사람의 결혼 증명서가 두 장 있으면 안 되는 것과 같아요.
보통 자원과 다른 점 (중요)
보통 자원: ID가 같으면 충돌
POST /users { id: "alice" } → 201
POST /users { id: "alice" } → 409 (같은 ID)
연관 리소스: ID가 달라도 "짝"이 같으면 충돌
POST /memberships { userId: "alice", channelId: "eng" } → cm-1 생성
POST /memberships { userId: "alice", channelId: "eng" } → 409
(cm-2로 새 ID를 받아도, alice+eng 짝이 겹치니까 거부!)
비유부터 표로 연결
| 비유 | API/코드 | 위험·주의 |
|---|---|---|
| 같은 부부의 증명서는 한 장 | (userId, groupId) 짝 유일 | ID 달라도 짝 겹치면 409 |
| 명부에 같은 사람 한 번만 | 중복 가입 차단 | 안 막으면 목록·삭제가 꼬임 |
7. 양쪽 이름표는 못 바꾼다 — 읽기 전용 필드
멤버십의 userId를 바꿀 수 있게 두면 사고가 납니다.
지금: cm-101 = { userId: "alice", channelId: "engineering", role: "admin" }
PATCH /memberships/cm-101 { userId: "bob" } // 만약 허용한다면?
→ "Alice의 admin 멤버십"이 "Bob의 admin 멤버십"으로 변신
→ Alice는 갑자기 쫓겨나고, Bob은 본인도 모르게 admin이 됨
이건 "관계를 고친" 게 아니라 완전히 다른 관계가 된 거예요. 그래서 양쪽 참조(userId, groupId)는 읽기 전용으로 잠급니다.
읽기 전용 필드: 만들 때 한 번 정해지면 그 뒤로는 못 바꾸는 필드. 일상 비유로, 결혼 증명서의 신랑·신부 이름 같은 거예요. (고치는 게 아니라 새로 발급해야 함)
대상을 정말 바꾸려면? 고치지 말고 지우고 새로 만드세요.
DELETE /memberships/cm-101 // Alice 멤버십 파기
POST /memberships { userId: "bob", channelId: "engineering", role: "admin" } // Bob 새로 발급
역할(role)이나 만료일(expireTime) 같은 정보는 바꿔도 됩니다. 바뀌면 안 되는 건 "누구와 누구"라는 짝뿐이에요.
읽기 전용 필드에 값을 보내면?
방식 1: 조용히 무시 (권장)
PATCH /memberships/cm-101 { userId: "bob", role: "admin" }
→ 200 { userId: "alice", role: "admin" } // userId는 무시, role만 반영
방식 2: 에러 반환 (엄격)
→ 400 Bad Request "userId is read-only"
어느 쪽이든 결과는 같아요. userId는 절대 안 바뀝니다.
비유부터 표로 연결
| 비유 | API/코드 | 위험·주의 |
|---|---|---|
| 증명서 이름은 고칠 수 없음 | userId·groupId 읽기 전용 | 바꾸면 다른 관계가 됨 |
| 정정이 아니라 재발급 | 삭제 후 재생성 | PATCH로 대상 바꾸기 시도 금지 |
| 결혼일은 도장 찍히는 정보 | role·expireTime 수정 가능 | 짝만 아니면 수정 OK |
8. 자주 쓰는 조회를 줄여 쓰기 — 별칭 메서드
"지미가 속한 그룹 목록"을 보려면 원래는 필터를 써야 해요.
ListMemberships({ filter: "userId='users/jimmy'" }) // 길다
그런데 이 조회는 너무 자주 쓰여서, 줄임 메서드를 따로 만듭니다.
ListUserGroups({ userId: "users/jimmy" }) // 짧고 직관적
이게 별칭 메서드예요.
별칭 메서드: 자주 쓰는 필터 조회를 짧게 줄여 만든 전용 메서드. 일상 비유로, 카페에서 "따뜻한 아메리카노 주세요" 대신 "따아요" 하는 거예요.
이름 짓는 규칙 — 단수 + 복수
ListUser Groups → 주어진 "사용자(단수)"의 "그룹들(복수)"
ListGroup Users → 주어진 "그룹(단수)"의 "사용자들(복수)"
HTTP 주소 패턴
GET /users/jimmy/groups → 지미가 속한 그룹들
GET /groups/1/users → 그룹 1에 속한 사용자들
핵심 — 별칭은 멤버십이 아니라 "상대 자원"을 돌려준다
ListGroupUsers({ groupId: "groups/1" })
→ User[] 반환 (사용자 목록!) ← Membership 아님
ListUserGroups({ userId: "users/jimmy" })
→ Group[] 반환 (그룹 목록!) ← Membership 아님
멤버십 자체(+ 역할·가입일)가 필요하면 그땐 ListMemberships
ListMemberships({ filter: "groupId='groups/1'" })
→ Membership[] 반환
비유부터 표로 연결
| 비유 | API/코드 | 위험·주의 |
|---|---|---|
| "따아요" 줄임 주문 | ListUserGroups / ListGroupUsers | 정보(role 등)는 안 옴 |
| 단수의 복수를 달라 | 이름 = 단수 + 복수 | 순서 헷갈리면 의미 반대됨 |
| 음료가 나옴 (영수증 아님) | 상대 자원 반환 | 멤버십 원하면 ListMemberships |
9. 가리키던 게 사라지면? — 참조 무결성
"축구부" 그룹을 지우려는데, 지미와 샐리의 멤버십이 이 그룹을 가리키고 있어요. 그냥 지우면 그 멤버십들은 사라진 그룹을 가리키는 유령 종이가 됩니다.
참조 무결성: 모든 참조가 실제로 존재하는 대상을 가리키는 상태. 일상 비유로, 명함에 적힌 회사가 진짜 있어야 하는 것과 같아요. (회사가 망했는데 명함만 남으면 유령 참조)
대응하는 길은 4가지예요.
방식 1: 캐스케이드 (같이 삭제)
그룹 지우면 → 관련 멤버십도 전부 자동 삭제
장점: 유령 없음
단점: 멤버 100만이면 삭제에 수 분, 연쇄 삭제 위험
판정: ⚠️ 대규모에선 비현실적
방식 2: 제한 (Restrict) ← 권장!
멤버십이 가리키는 동안엔 그룹 삭제 자체를 막음
DELETE /groups/축구부 → 409 "2개 멤버십이 참조 중. 먼저 정리하세요"
장점: 가장 안전, 실수로 인한 데이터 유실 없음
단점: 지우기 전에 정리 작업 필요
판정: ✅ 1순위
방식 3: null 설정
그룹 지우면 → 멤버십의 groupId를 null로
단점: 100만 건 업데이트 + groupId가 null인 멤버십은 의미가 없음
판정: ⚠️ 연관 리소스엔 부적합
방식 4: 방치 (그대로 둠)
그룹 지워도 멤버십은 사라진 그룹을 그대로 가리킴 (유령 종이)
장점: 단순, 즉시 삭제
단점: 클라이언트가 매번 참조가 유효한지 확인해야 함
판정: ✅ 2순위 (단순한 시스템)
실무 권장 정리
1순위: 제한(Restrict) — 가장 안전. 멤버를 먼저 정리해야 그룹 삭제 가능.
2순위: 방치(Leave) — 가장 단순. 유령 참조는 클라이언트가 처리.
비추천: 캐스케이드 / null 설정 — 대규모에서 비현실적.
비유부터 표로 연결
| 비유 | API/코드 | 위험·주의 |
|---|---|---|
| 회사 망하면 명함도 회수 | 캐스케이드 | 명함 100만 장이면 회수에 한참 |
| 직원 있으면 회사 폐업 막기 | 제한(Restrict) ✅ | 먼저 직원 정리 필요 |
| 명함의 회사 칸을 공란으로 | null 설정 | 어느 회사였는지 사라짐 |
| 망한 회사 명함 그냥 둠 | 방치 ✅ | 받는 쪽이 유효한지 확인해야 함 |
한 걸음 더 ▸ 13장(교차 참조)에서도 똑같이 "방치"를 권장했어요. 단순 참조든 연관 리소스든, 대규모에선 즉시 삭제 + 유령은 클라이언트 처리가 현실적입니다.
10. 백엔드로 직접 만들어보기 (Flask + SQLite)
말로만 보면 손에 안 잡히니, 핵심 동작 3개를 진짜 코드로 봅시다.
생성 — 고유성 검사로 409 처리
@app.route("/memberships", methods=["POST"])
def create_membership():
data = request.json
user_id, group_id = data["userId"], data["groupId"]
db = sqlite3.connect("membership.db")
# 같은 짝이 이미 있나? → 있으면 409
existing = db.execute(
"SELECT id FROM memberships WHERE user_id=? AND group_id=?",
(user_id, group_id)).fetchone()
if existing:
return jsonify({"error": "이미 이 그룹의 멤버입니다"}), 409
membership_id = f"memberships/{uuid.uuid4().hex[:8]}"
db.execute("INSERT INTO memberships (id, user_id, group_id, role) VALUES (?,?,?,?)",
(membership_id, user_id, group_id, data.get("role", "member")))
db.commit()
return jsonify({"id": membership_id, ...}), 201
별칭 메서드 — JOIN으로 "상대 자원"을 돌려줌
@app.route("/groups/<group_id>/users", methods=["GET"])
def list_group_users(group_id):
db = sqlite3.connect("membership.db")
rows = db.execute("""
SELECT u.id, u.display_name, u.email
FROM users u
JOIN memberships m ON u.id = m.user_id
WHERE m.group_id = ?
""", (group_id,)).fetchall()
# Membership이 아니라 User를 반환!
return jsonify({"users": [{"id": r[0], "displayName": r[1]} for r in rows]})
그룹 삭제 — 제한(Restrict)으로 참조 무결성
@app.route("/groups/<group_id>", methods=["DELETE"])
def delete_group(group_id):
db = sqlite3.connect("membership.db")
count = db.execute(
"SELECT COUNT(*) FROM memberships WHERE group_id=?",
(group_id,)).fetchone()[0]
if count > 0: # 참조 중이면 삭제 거부
return jsonify({"error": f"{count}개 멤버십이 참조 중"}), 409
db.execute("DELETE FROM groups_ WHERE id=?", (group_id,))
db.commit()
return "", 204
빈칸 채우기 연습 (스스로 해보기)
# Q: 중복 멤버십을 막는 SQL의 빈칸은?
existing = db.execute(
"SELECT id FROM memberships WHERE user_id=? AND ______=?",
(user_id, group_id)).fetchone()
# 답: group_id (짝의 양쪽을 모두 검사해야 하니까)
독립 적용 (혼자 설계해보기)
ListUserGroups("/users/<user_id>/groups")를 위 ListGroupUsers를 본떠 직접 작성해보세요.
힌트: groups_ 와 memberships를 JOIN, WHERE는 m.user_id = ?, 반환은 Group[].
10.5. 전체 API 한눈에 보기 — 메서드가 17개가 됐어요
여기까지 부분부분 만들어 봤죠. 그런데 막상 다 모아보면 "어? 이렇게 많았나?" 싶어요. 끝내기 전에 지금까지 붙인 메서드를 전부 한 장에 모아 세어봅시다.
이게 중요한 이유부터. 1절에서 "User에 groupIds 배열 하나만 두면 끝"처럼 보였던 게 기억나죠? 연관 리소스로 제대로 만들면 그 단순함이 사라지고, 자원 3개에 메서드 17개짜리 인터페이스가 됩니다. "관계 하나당 7개"라는 11절의 수치는 멤버십 한 줄만 센 거예요. 실제로는 부모 자원(User·Group)까지 합쳐 전체 표면이 얼마나 커지는지 봐야 비용이 손에 잡혀요.
일상 비유 먼저
동아리 관리 사무실을 차린다고 해봐요.
- 학생 서류함: 학생을 등록·조회·목록·수정·폐기 → 처리 창구 5개
- 동아리 서류함: 동아리를 등록·조회·목록·수정·폐기 → 처리 창구 5개
- 가입 증명서함: 멤버십을 발급·조회·목록·수정·파기 → 처리 창구 5개
- 빠른 조회 창구 2개: "이 학생의 동아리들", "이 동아리의 학생들"
창구를 다 세면 5 + 5 + 5 + 2 = 17개. 배열 하나로 끝날 줄 알았는데, 제대로 차린 사무실은 창구가 17개나 필요해요.
비유부터 표로 연결
| 비유 | API/코드 | 위험·주의 |
|---|---|---|
| 학생 서류함 처리 창구 5개 | User 표준 메서드 (Create/Get/List/Update/Delete) | 부모 자원도 메서드를 다 가짐 |
| 가입 증명서함 처리 창구 5개 | Membership 표준 메서드 5개 | 정보 없으면 Get·Update는 줄어듦 |
| 빠른 조회 창구 2개 | 별칭 메서드 (ListUserGroups·ListGroupUsers) | 멤버십이 아니라 상대 자원을 돌려줌 |
| 창구 17개짜리 사무실 | GroupApi 전체 인터페이스 | 단순 배열보다 훨씬 커짐 = 비용 |
누적 카운트 — 한 줄씩 더해보기
User 메서드: 5개 (Create, Get, List, Update, Delete)
Group 메서드: 5개 (Create, Get, List, Update, Delete) → 누적 10
Membership 메서드: 5개 (Create, Get, List, Update, Delete) → 누적 15
별칭 메서드: 2개 (ListGroupUsers, ListUserGroups) → 누적 17
─────────────────────────────────────────────
합계: 17개 메서드
11절의 "관계당 7개(표준 5 + 별칭 2)"는 이 17개 중 멤버십 줄(5) + 별칭(2) 만 떼어 센 숫자예요. 부모 자원 둘(User 5 + Group 5 = 10)을 합치면 전체 API 표면은 17개로 불어납니다.
통합 인터페이스 — 부모 자원 + 연관 자원을 한 클래스에
이 17개를 한 곳에 모은 게 최종 API 정의예요.
@post·@get 같은 데코레이터는 "이 메서드는 어떤 HTTP 주소·동작에 연결된다"는 꼬리표라고 보면 돼요.
class GroupApi:
# User 표준 5개
@post("/users") def CreateUser(req): ...
@get("/users/{id}") def GetUser(req): ...
@get("/users") def ListUsers(req): ...
@patch("/users/{id}") def UpdateUser(req): ...
@delete("/users/{id}") def DeleteUser(req): ...
# Group 표준 5개
@post("/groups") def CreateGroup(req): ...
@get("/groups/{id}") def GetGroup(req): ...
@get("/groups") def ListGroups(req): ...
@patch("/groups/{id}") def UpdateGroup(req): ...
@delete("/groups/{id}") def DeleteGroup(req): ...
# Membership 표준 5개 (연관 자원)
@post("/memberships") def CreateMembership(req): ...
@get("/memberships/{id}") def GetMembership(req): ...
@get("/memberships") def ListMemberships(req): ...
@patch("/memberships/{id}") def UpdateMembership(req): ...
@delete("/memberships/{id}") def DeleteMembership(req): ...
# 별칭 2개 — 부모 주소 밑에 연관 자원을 엮음
@get("/groups/{groupId}/users") def ListGroupUsers(req): ... # 이 그룹의 학생들
@get("/users/{userId}/groups") def ListUserGroups(req): ... # 이 학생의 그룹들
핵심은 별칭 두 줄이에요.
@get("/groups/{groupId}/users")는 부모 자원(Group) 주소 밑에 연관 자원(멤버십이 잇는 User들)을 끼워 넣은 거예요.
그래서 통합 인터페이스 하나로 "부모 자원"과 "그 부모에 엮인 상대"를 같이 노출합니다.
잘못된 예 vs 올바른 예 (셈하기)
잘못된 셈 — 멤버십만 세고 끝내기
"연관 리소스 비용? 7개네." ← 멤버십 5 + 별칭 2만 셈
→ 부모 자원 10개를 빠뜨려서 전체 표면을 과소평가
올바른 셈 — 부모까지 누적
User 5 + Group 5 + Membership 5 + 별칭 2 = 17개
→ "배열 하나"로 보였던 게 실제론 17개 창구임을 정직하게 인식
한 걸음 더 ▸ 만약 정보가 전혀 없어서 멤버십에 Get·Update가 빠지면, 멤버십은 3개(Create·List·Delete)로 줄어 전체는 15개가 돼요. 정보 유무가 표면 크기를 직접 바꾼다는 걸 숫자로 확인할 수 있죠.
11. 언제 연관 리소스를 쓰나 — 트레이드오프
연관 리소스는 강력하지만 공짜가 아니에요.
비용 1 — API가 커진다
관계 하나당 최대 7개 메서드(표준 5 + 별칭 2)가 늘어요.
넣기/빼기 방식(다음 장): POST /groups/1/users:add (메서드 1~4개, 정보 불가)
연관 리소스 방식: Create/Get/List/Update/Delete + 별칭 2개 = 최대 7개
정보가 필요 없으면 더 단순한 방식이 나을 수 있어요.
비용 2 — 정보가 따로 논다
그룹 기본 정보와 멤버 목록을 한 번에 못 가져와요.
GET /groups/1 → { name, description } (멤버는 없음)
GET /groups/1/users → [멤버 목록] (따로 호출)
→ 클라이언트가 2번 호출
그래서 언제? — 판단 한 줄
관계에 붙일 정보가 있나?
├─ 있다 → 연관 리소스 (14장)
└─ 없다 → 관계의 있다/없다만 필요
├─ 단순함이 우선 → 넣기/빼기 (다음 장)
└─ CRUD 일관성 우선 → 연관 리소스 (정보 없이도)
실전 판단 5문제 (정보 유무로 가른다)
1) GitHub Star (사용자 ↔ 저장소)
정보: 없음 (찍었다/안 찍었다 뿐)
→ 정보 없음. 넣기/빼기가 적합. 연관 리소스는 과잉.
2) Slack 채널 멤버 (사용자 ↔ 채널)
정보: 역할, 가입일, 알림설정, 마지막 읽은 위치 (4개)
→ 정보 많음. 연관 리소스(ChannelMembership).
3) 블로그 태그 (글 ↔ 태그)
정보: 없음 (붙었다/안 붙었다)
→ 넣기/빼기. 단, "태그 순서"가 필요하면 순서가 정보이므로 연관 리소스.
4) 수강 등록 (학생 ↔ 강의)
정보: 진도율, 수료여부, 만료일
→ 연관 리소스(CourseEnrollment). 진도율은 핵심 데이터.
5) 팀 배정 (직원 ↔ 프로젝트)
정보: 역할(PM/개발/디자인), 참여율, 시작·종료일
→ 연관 리소스(ProjectAssignment).
잘못 고르면 생기는 일
| 올바른 선택 | 잘못 골랐을 때 | 생기는 문제 |
|---|---|---|
| 연관 리소스가 정답인데 넣기/빼기 사용 | 정보 저장 불가 | 핵심 데이터 누락, 나중에 갈아엎기(breaking change) |
| 넣기/빼기가 정답인데 연관 리소스 사용 | 메서드 7개 과잉 | API 복잡, 클라이언트 부담 |
| 단순 참조(13장)가 정답인데 연관 리소스 | 불필요한 중간 자원 | 간단한 걸 복잡하게 |
한 걸음 더 ▸ 나중에 정보가 붙을 것 같으면 처음부터 연관 리소스로 가는 게 안전해요. 넣기/빼기로 시작했다가 연관 리소스로 바꾸면 기존 클라이언트가 깨지는 큰 변경이 됩니다.
마무리 — 이 장 한 장 요약
다대다 + 관계 정보 → 관계를 자원으로 (연관 리소스)
만들기·지우기·나열하기는 필수, 조회·수정은 정보 있을 때만
같은 짝은 하나만 (고유성), 양쪽 이름표는 못 바꿈 (읽기 전용)
자주 쓰는 조회는 별칭 메서드로 줄여 쓰되 상대 자원을 돌려줌
대상이 사라질 때는 제한(권장) 또는 방치로 처리
정보가 없으면 더 단순한 방식이 나을 수 있다
다음 장 예고: 관계에 붙일 정보가 전혀 없을 때 쓰는 더 단순한 "넣기/빼기(add/remove)" 방식을 다룹니다. 지금 몰라도 됩니다.
클릭하거나 Space를 눌러 뒤집기